Next.js Authentication in 2026: Auth.js vs Clerk vs Custom

Diagram comparing Auth.js, Clerk, and custom authentication approaches in Next.js

Diagram comparing Auth.js, Clerk, and custom authentication approaches in Next.js

Next.js Authentication in 2026: Auth.js vs Clerk vs Custom Authentication

Authentication is one of the few parts of a Next.js app you cannot afford to get wrong. Get it right and users barely notice it. Get it wrong and you've built an open door into your data. In 2026, with Next.js 16 having reshaped how request interception works, the "put a check in middleware and call it done" pattern that used to pass for authentication is officially retired.

This guide walks through what authentication actually means in a modern Next.js App Router project, how Auth.js, Clerk, and a fully custom implementation each approach the problem, and how to decide which one fits your project.

Authentication vs Authorization

These two words get used interchangeably, and that's where a lot of security bugs start.

  • Authentication answers "who is this user?" — verifying credentials (a password, an OAuth token, a passkey) and issuing some proof of identity, usually a session or a JWT.
  • Authorization answers "is this user allowed to do this?" — checking permissions, roles, or ownership before returning data or performing an action.

A login form is authentication. The check that stops User A from editing User B's invoice is authorization. Systems get breached far more often because authorization was assumed rather than enforced than because authentication itself was broken. Every section below treats them as two distinct problems that both need solving.

Sessions, Cookies, and JWTs

Next.js authentication libraries build on three lower-level primitives:

  • Sessions — server-side state tied to a user, referenced by an opaque identifier the client holds.
  • Cookies — the most common way to carry that identifier (or a signed/encrypted token) between client and server. httpOnly, secure, and sameSite cookie attributes are your first line of defense against token theft and CSRF.
  • JWTs (JSON Web Tokens) — self-contained, signed tokens that encode claims (user ID, roles, expiry) without a database lookup. They're fast to verify but harder to revoke early, since the token remains valid until it expires unless you build a revocation list.

A common pattern in 2026: a short-lived JWT access token paired with a longer-lived, database-backed refresh session, so you get JWT speed without losing the ability to kill a session on demand.

Where Authentication Logic Lives Now: proxy.ts

Next.js 16 renamed middleware.ts to proxy.ts, and the exported function from middleware() to proxy(). This isn't a cosmetic rename — it reflects Vercel's own guidance that the network-boundary file should stay a thin routing and redirect layer, not the primary enforcement point for authentication. proxy.ts also now runs on the Node.js runtime rather than defaulting to the Edge runtime.

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

export default function proxy(request: NextRequest) {
  const sessionCookie = request.cookies.get('session')

  if (!sessionCookie && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*'],
}

If you're upgrading an older project, middleware.ts still works today but is deprecated — Next.js provides a codemod (npx @next/codemod middleware-to-proxy .) to migrate automatically. VERIFY BEFORE PUBLISHING: confirm the exact codemod command against the current Next.js upgrade guide at the time of publishing, since codemod naming has shifted across 16.x releases.

The important mental model shift: proxy.ts is good for a cheap, redirect-level "is there any session cookie at all" check that improves UX (bouncing obviously logged-out users before they hit a protected page). It is not sufficient authorization on its own — real enforcement belongs in Server Components, Route Handlers, Server Actions, and your data-access layer, checked against the actual verified session, not just cookie presence.

Protected Routes: Server-Side First

In the App Router, the most reliable way to protect a route is to check the session inside the Server Component or layout that renders it, and redirect if it's missing:

// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/session'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await getSession()

  if (!session) {
    redirect('/login')
  }

  return <>{children}</>
}

This runs on every request to anything under /dashboard, server-side, with no way for a client to bypass it by disabling JavaScript or spoofing a cookie the server doesn't actually validate.

Client-Side Considerations

Client Components can't securely gate access — a useEffect that redirects unauthenticated users is a UX nicety, not a security boundary, since the component's initial render (and any data fetched before the redirect fires) can still be visible momentarily or accessible via the network tab. Client-side checks are for showing/hiding UI (a "Sign In" vs "Account" button), not for protecting data. Any sensitive data must be fetched server-side, after a real session check, and only passed to the client once authorization has already happened.

Auth.js (NextAuth)

Auth.js is the direct descendant of NextAuth.js, built for the App Router with a single NextAuth() call that returns handlers, sign-in/sign-out helpers, and an auth() function for reading the session server-side.

// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'

export const { GET, POST } = handlers
// proxy.ts
export { auth as proxy } from '@/auth'

Reading the session in a Server Component:

import { auth } from '@/auth'

export default async function Page() {
  const session = await auth()
  if (!session) return <p>Not signed in</p>
  return <p>Welcome, {session.user?.name}</p>
}

Strengths: open source, self-hosted (no third-party account required at runtime), deep provider ecosystem (OAuth, email/passwordless, credentials), full control over the database adapter and session strategy.

Trade-offs: you own more of the surrounding infrastructure — database schema for users/sessions/accounts (via an adapter such as Drizzle, Prisma, or a custom adapter), email delivery for passwordless/verification flows, and UI for sign-in/sign-up screens. VERIFY BEFORE PUBLISHING: confirm current Auth.js adapter list and any v5-vs-v4 API differences against authjs.dev before this article goes live, since Auth.js has iterated its adapter and configuration APIs across major versions.

Clerk

Clerk is a hosted authentication platform: it provides prebuilt UI components, session management, and its own middleware helper, clerkMiddleware(), which now lives in proxy.ts under Next.js 16.

// proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect()
  }
})

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico)).*)',
    '/(api|trpc)(.*)',
  ],
}

clerkMiddleware() does not automatically protect every route just by being present — it makes Clerk's auth state available across the request. Routes are only actually gated where you explicitly call auth.protect() (as above) or check auth() inside a Server Component, Route Handler, or Server Action. Authorization still needs to be enforced close to the resource it protects, not assumed from the middleware layer alone.

import { auth } from '@clerk/nextjs/server'

export default async function DashboardPage() {
  const { userId } = await auth()
  if (!userId) return null // proxy already redirected unauthenticated users
  return <p>User: {userId}</p>
}

Strengths: prebuilt, accessible sign-in/sign-up UI components; built-in multi-factor auth, organizations/teams, and session management; very little backend code to write.

Trade-offs: hosted dependency — your authentication is only as available as Clerk's infrastructure; pricing scales with monthly active users (see the platform comparison article for a full breakdown); less control over the exact database schema underlying sessions and users. VERIFY BEFORE PUBLISHING: confirm current Clerk package versions and API surface against clerk.com/docs, since Clerk has shipped major version changes to its Next.js SDK.

Custom Authentication

Building your own means implementing password hashing (via a modern algorithm like Argon2 or bcrypt), session or JWT issuance, cookie handling, and every edge case — password reset, email verification, rate limiting on login attempts — yourself.

// app/api/login/route.ts
import { cookies } from 'next/headers'
import { verifyPassword } from '@/lib/auth'
import { createSession } from '@/lib/session'

export async function POST(request: Request) {
  const { email, password } = await request.json()
  const user = await verifyPassword(email, password)

  if (!user) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  const sessionToken = await createSession(user.id)

  const cookieStore = await cookies()
  cookieStore.set('session', sessionToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })

  return Response.json({ success: true })
}

Strengths: total control over data model, session behavior, and UX; no per-user vendor pricing; no third-party dependency for your core auth flow.

Trade-offs: you are now responsible for every security detail — timing-safe password comparison, session fixation, secure token generation, brute-force protection — that a mature library has already hardened through years of real-world attacks. This is a meaningful, ongoing maintenance commitment, not a one-time build.

Security Considerations That Apply Regardless of Approach

  • Always set httpOnly, secure, and an appropriate sameSite value on session cookies.
  • Never trust a client-supplied user ID or role — re-derive identity from the verified session on every server-side check.
  • Rotate or invalidate sessions on password change and on logout, not just client-side.
  • Rate-limit authentication endpoints to blunt credential-stuffing and brute-force attempts.
  • Treat proxy.ts checks as a UX optimization, not your security boundary — the real check belongs at the data-access layer, covered in depth in the API security article.

When Each Approach Makes Sense

  • Auth.js fits teams that want open-source control, are comfortable owning a database schema and adapter, and need flexible provider support without a per-user hosted bill.
  • Clerk fits teams that want to ship fast with polished, prebuilt UI and don't mind a hosted dependency and usage-based pricing — especially useful when you also need organizations, invitations, or MFA out of the box.
  • Custom authentication fits teams with very specific compliance, data-residency, or architectural requirements that off-the-shelf libraries can't accommodate, and who have the security expertise to maintain it correctly over time.

There is no universally "best" option here — the right choice depends on your team's size, security expertise, budget, and how much of the authentication surface you actually want to own. The next article in this series compares Clerk, Auth0, Supabase Auth, and Auth.js side by side to help you decide, and the one after that goes deep on securing the API routes and Server Actions that sit behind whichever authentication approach you choose.


Related reading:

Comments

Popular posts from this blog

Why Python is Still the King of AI Programming in 2026: A Deep Dive

Top 5 AI Automation Tools Every Developer Must Use in 2026

The AI Revolution in Full Stack Development: 2026 Comprehensive Guide